Skip to content

Check if merge is aborted during HNSW graph construction#16368

Open
jeho-rpls wants to merge 5 commits into
apache:mainfrom
jeho-rpls:hnsw-merge-abort-16367
Open

Check if merge is aborted during HNSW graph construction#16368
jeho-rpls wants to merge 5 commits into
apache:mainfrom
jeho-rpls:hnsw-merge-abort-16367

Conversation

@jeho-rpls

@jeho-rpls jeho-rpls commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #16367.

HNSW graph construction never checked OneMerge's abort flag. It is a pure-CPU loop that performs no writes, so IndexWriter#rollback and abortMerges() blocked until the entire graph was built. For production-sized vector segments that means tens of minutes (measurements in the issue).

Changes

  • HnswBuilder gains setAbortCheck(IORunnable), and HnswGraphBuilder invokes it before every node insertion in addGraphNodeInternal.
    • This is the single point that all insertion paths funnel through: full rebuild via addVectors, graph join via MergingHnswGraphBuilder, and HnswConcurrentMergeBuilder workers (which forward the check).
  • Lucene99HnswVectorsWriter passes mergeState::checkAborted into the graph mergers (new constructor overloads, existing constructors unchanged).
  • Flush-time graph builds are unaffected (no check set), matching the null contract of checkIntegrity(OneMerge) from Check if merge is aborted during file integrity checksums #16281.
  • Overhead: one null check per node insertion, where each insertion costs hundreds of vector comparisons.

Testing

  • New TestHnswMergeAbort exercises all three merge paths end-to-end (rollback during a forceMerge of a 48k x 96d index). Without the fix, rollback blocks until the build finishes. With the fix, it returns in milliseconds.
    • full rebuild (every segment above 40% deletions, so no base graph is eligible): blocked 27 s without the fix
    • graph join (deletion-free segments, MergingHnswGraphBuilder): blocked 31 s
    • concurrent merge (numMergeWorkers = 2, HnswConcurrentMergeBuilder): blocked 14 s
  • ./gradlew tidy and :lucene:core:check pass.
  • The approach was also validated on a production Elasticsearch 9.4.2 cluster via a 10.4 backport.

Graph construction is a pure-CPU loop that performs no writes, so it
never observed OneMerge's abort flag: IndexWriter#rollback/abortMerges
blocked until the entire graph was built (tens of minutes for large
vector segments).

Wire MergeState#checkAborted from Lucene99HnswVectorsWriter through the
HNSW graph mergers into the builders, and invoke it before every node
insertion in HnswGraphBuilder#addGraphNodeInternal - the single point
that all insertion paths (full rebuild, graph-join, concurrent workers)
funnel through. Flush-time graph builds are unaffected (no check set).
@jeho-rpls

Copy link
Copy Markdown
Contributor Author

The CI failure is unrelated to this change: TestForceNoBulkScoringQuery.testBulkScoringIsDisabled (lucene/monitor) fails on a clean main checkout (a9eb52b, no changes from this PR) with the same seed:

gradlew -p lucene/monitor test --tests TestForceNoBulkScoringQuery.testBulkScoringIsDisabled -Dtests.seed=BDD18A3ADD885193 -Dtests.locale=ii-CN -Dtests.timezone=Etc/Zulu

fails deterministically with Expected exception AssertionError but no exception was thrown (test environment: codec=CheapBastard). Re-pushed to re-trigger CI with fresh seeds.

@jeho-rpls

Copy link
Copy Markdown
Contributor Author

Opened #16369 with a fix for the unrelated test failure above.

jeho-rpls and others added 3 commits July 8, 2026 12:31
Add a variant with numMergeWorkers=2 so the abort check forwarding in
HnswConcurrentMergeBuilder is exercised, and relax the rollback bound
from 5s to 10s for slow CI machines. Without the fix the three variants
block rollback for 27s (full rebuild), 31s (graph join) and 14s
(concurrent) on this index.
@msokolov

Copy link
Copy Markdown
Contributor

Is this really needed? When do we ever call abortMerges()? It looks to me as if it gets called when an exception happens while closing an IndexWriter, or explicitly rolling back. Seem like kind of exceptional cases, although maybe we would want rollbacks to be faster? Do other merge operations check for aborted status?

@jeho-rpls

Copy link
Copy Markdown
Contributor Author

Hi @msokolov, thanks for taking a look!

Is this really needed? When do we ever call abortMerges()? It looks to me as if it gets called when an exception happens while closing an IndexWriter, or explicitly rolling back. Seem like kind of exceptional cases

abortMerges() is routine at the deployment level even though it looks exceptional at the API level.
Elasticsearch closes its shard engines through IndexWriter#rollback on node shutdown, so every rolling restart of a data node goes through it.

That is how we hit this.

  • When a node is asked to shut down while an HNSW merge is in the middle of graph construction, that rollback blocks until the build completes. In containerized deployments it easily outlives the pod termination grace period, so the process ends up force-killed mid-merge.
  • In our case, a routine rolling restart of a vector-heavy cluster blocked for 24–31 minutes per node behind a single in-flight merge, past the 30-minute pod grace in the worst case (measurements in HNSW graph construction during merge never checks for abort, blocking IndexWriter#rollback for tens of minutes #16367).

Elasticsearch reports hitting a similar issue in production and has an open downstream workaround (elastic/elasticsearch#152342), but it lives in their stateless plugin only, and the workaround's author notes it is partial coverage. A check in the graph builder covers every directory implementation.

although maybe we would want rollbacks to be faster?

Yes, exactly. Faster rollback is the whole motivation. The wait also buys nothing. The writer is rolling back, so the graph being built is discarded the moment it completes.

Do other merge operations check for aborted status?

They do.

Graph construction is the odd one out. It performs no writes and no checksum reads for tens of minutes, so it never observes the flag. In our profiling it was 93.6% of the merge CPU, while the checksum phase covered by #16281 was 0.1%.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HNSW graph construction during merge never checks for abort, blocking IndexWriter#rollback for tens of minutes

2 participants